Golang源码探索(二) 协程的实现原理

您所在的位置:网站首页 go 协程实现 Golang源码探索(二) 协程的实现原理

Golang源码探索(二) 协程的实现原理

2022-06-03 08:07| 来源: 网络整理| 查看: 265

Golang源码探索(二) 协程的实现原理 zkweb · · 22959 次点击 · · 开始浏览     这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。 第一次,站长亲自招 Gopher 了>>>

Golang最大的特色可以说是协程(goroutine)了, 协程让本来很复杂的异步编程变得简单, 让程序员不再需要面对回调地狱, 虽然现在引入了协程的语言越来越多, 但go中的协程仍然是实现的是最彻底的. 这篇文章将通过分析golang的源代码来讲解协程的实现原理.

这个系列分析的golang源代码是Google官方的实现的1.9.2版本, 不适用于其他版本和gccgo等其他实现, 运行环境是Ubuntu 16.04 LTS 64bit.

核心概念

要理解协程的实现, 首先需要了解go中的三个非常重要的概念, 它们分别是G, M和P, 没有看过golang源代码的可能会对它们感到陌生, 这三项是协程最主要的组成部分, 它们在golang的源代码中无处不在.

G (goroutine)

G是goroutine的头文字, goroutine可以解释为受管理的轻量线程, goroutine使用go关键词创建.

举例来说, func main() { go other() }, 这段代码创建了两个goroutine, 一个是main, 另一个是other, 注意main本身也是一个goroutine.

goroutine的新建, 休眠, 恢复, 停止都受到go运行时的管理. goroutine执行异步操作时会进入休眠状态, 待操作完成后再恢复, 无需占用系统线程, goroutine新建或恢复时会添加到运行队列, 等待M取出并运行.

M (machine)

M是machine的头文字, 在当前版本的golang中等同于系统线程. M可以运行两种代码:

go代码, 即goroutine, M运行go代码需要一个P 原生代码, 例如阻塞的syscall, M运行原生代码不需要P

M会从运行队列中取出G, 然后运行G, 如果G运行完毕或者进入休眠状态, 则从运行队列中取出下一个G运行, 周而复始. 有时候G需要调用一些无法避免阻塞的原生代码, 这时M会释放持有的P并进入阻塞状态, 其他M会取得这个P并继续运行队列中的G. go需要保证有足够的M可以运行G, 不让CPU闲着, 也需要保证M的数量不能过多.

P (process)

P是process的头文字, 代表M运行G所需要的资源. 一些讲解协程的文章把P理解为cpu核心, 其实这是错误的. 虽然P的数量默认等于cpu核心数, 但可以通过环境变量GOMAXPROC修改, 在实际运行时P跟cpu核心并无任何关联.

P也可以理解为控制go代码的并行度的机制, 如果P的数量等于1, 代表当前最多只能有一个线程(M)执行go代码, 如果P的数量等于2, 代表当前最多只能有两个线程(M)执行go代码. 执行原生代码的线程数量不受P控制.

因为同一时间只有一个线程(M)可以拥有P, P中的数据都是锁自由(lock free)的, 读写这些数据的效率会非常的高.

数据结构

在讲解协程的工作流程之前, 还需要理解一些内部的数据结构.

G的状态 空闲中(_Gidle): 表示G刚刚新建, 仍未初始化 待运行(_Grunnable): 表示G在运行队列中, 等待M取出并运行 运行中(_Grunning): 表示M正在运行这个G, 这时候M会拥有一个P 系统调用中(_Gsyscall): 表示M正在运行这个G发起的系统调用, 这时候M并不拥有P 等待中(_Gwaiting): 表示G在等待某些条件完成, 这时候G不在运行也不在运行队列中(可能在channel的等待队列中) 已中止(_Gdead): 表示G未被使用, 可能已执行完毕(并在freelist中等待下次复用) 栈复制中(_Gcopystack): 表示G正在获取一个新的栈空间并把原来的内容复制过去(用于防止GC扫描) M的状态

M并没有像G和P一样的状态标记, 但可以认为一个M有以下的状态:

自旋中(spinning): M正在从运行队列获取G, 这时候M会拥有一个P 执行go代码中: M正在执行go代码, 这时候M会拥有一个P 执行原生代码中: M正在执行原生代码或者阻塞的syscall, 这时M并不拥有P 休眠中: M发现无待运行的G时会进入休眠, 并添加到空闲M链表中, 这时M并不拥有P

自旋中(spinning)这个状态非常重要, 是否需要唤醒或者创建新的M取决于当前自旋中的M的数量.

P的状态 空闲中(_Pidle): 当M发现无待运行的G时会进入休眠, 这时M拥有的P会变为空闲并加到空闲P链表中 运行中(_Prunning): 当M拥有了一个P后, 这个P的状态就会变为运行中, M运行G会使用这个P中的资源 系统调用中(_Psyscall): 当go调用原生代码, 原生代码又反过来调用go代码时, 使用的P会变为此状态 GC停止中(_Pgcstop): 当gc停止了整个世界(STW)时, P会变为此状态 已中止(_Pdead): 当P的数量在运行时改变, 且数量减少时多余的P会变为此状态 本地运行队列

在go中有多个运行队列可以保存待运行(_Grunnable)的G, 它们分别是各个P中的本地运行队列和全局运行队列. 入队待运行的G时会优先加到当前P的本地运行队列, M获取待运行的G时也会优先从拥有的P的本地运行队列获取, 本地运行队列入队和出队不需要使用线程锁.

本地运行队列有数量限制, 当数量达到256个时会入队到全局运行队列. 本地运行队列的数据结构是环形队列, 由一个256长度的数组和两个序号(head, tail)组成.

当M从P的本地运行队列获取G时, 如果发现本地队列为空会尝试从其他P盗取一半的G过来, 这个机制叫做Work Stealing, 详见后面的代码分析.

全局运行队列

全局运行队列保存在全局变量sched中, 全局运行队列入队和出队需要使用线程锁. 全局运行队列的数据结构是链表, 由两个指针(head, tail)组成.

空闲M链表

当M发现无待运行的G时会进入休眠, 并添加到空闲M链表中, 空闲M链表保存在全局变量sched. 进入休眠的M会等待一个信号量(m.park), 唤醒休眠的M会使用这个信号量.

go需要保证有足够的M可以运行G, 是通过这样的机制实现的:

入队待运行的G后, 如果当前无自旋的M但是有空闲的P, 就唤醒或者新建一个M 当M离开自旋状态并准备运行出队的G时, 如果当前无自旋的M但是有空闲的P, 就唤醒或者新建一个M 当M离开自旋状态并准备休眠时, 会在离开自旋状态后再次检查所有运行队列, 如果有待运行的G则重新进入自旋状态

因为"入队待运行的G"和"M离开自旋状态"会同时进行, go会使用这样的检查顺序:

入队待运行的G => 内存屏障 => 检查当前自旋的M数量 => 唤醒或者新建一个M 减少当前自旋的M数量 => 内存屏障 => 检查所有运行队列是否有待运行的G => 休眠

这样可以保证不会出现待运行的G入队了, 也有空闲的资源P, 但无M去执行的情况.

空闲P链表

当P的本地运行队列中的所有G都运行完毕, 又不能从其他地方拿到G时, 拥有P的M会释放P并进入休眠状态, 释放的P会变为空闲状态并加到空闲P链表中, 空闲P链表保存在全局变量sched 下次待运行的G入队时如果发现有空闲的P, 但是又没有自旋中的M时会唤醒或者新建一个M, M会拥有这个P, P会重新变为运行中的状态.

工作流程(概览)

下图是协程可能出现的工作状态, 图中有4个P, 其中M1~M3正在运行G并且运行后会从拥有的P的运行队列继续获取G:

只看这张图可能有点难以想象实际的工作流程, 这里我根据实际的代码再讲解一遍:

package main import ( "fmt" "time" ) func printNumber(from, to int, c chan int) { for x := from; x 重新执行schedule函数

schedule函数的处理如下:

如果当前GC需要停止整个世界(STW), 则调用stopm休眠当前的M 如果M拥有的P中指定了需要在安全点运行的函数(P.runSafePointFn), 则运行它 快速获取待运行的G, 以下处理如果有一个获取成功后面就不会继续获取 如果当前GC正在标记阶段, 则查找有没有待运行的GC Worker, GC Worker也是一个G 为了公平起见, 每61次调度从全局运行队列获取一次G, (一直从本地获取可能导致全局运行队列中的G不被运行) 从P的本地运行队列中获取G, 调用runqget函数 快速获取失败时, 调用findrunnable函数获取待运行的G, 会阻塞到获取成功为止 如果当前GC需要停止整个世界(STW), 则调用stopm休眠当前的M 如果M拥有的P中指定了需要在安全点运行的函数(P.runSafePointFn), 则运行它 如果有析构器待运行则使用"运行析构器的G" 从P的本地运行队列中获取G, 调用runqget函数 从全局运行队列获取G, 调用globrunqget函数, 需要上锁 从网络事件反应器获取G, 函数netpoll会获取哪些fd可读可写或已关闭, 然后返回等待fd相关事件的G 如果获取不到G, 则执行Work Stealing 调用runqsteal尝试从其他P的本地运行队列盗取一半的G 如果还是获取不到G, 就需要休眠M了, 接下来是休眠的步骤 再次检查当前GC是否在标记阶段, 在则查找有没有待运行的GC Worker, GC Worker也是一个G 再次检查如果当前GC需要停止整个世界, 或者P指定了需要再安全点运行的函数, 则跳到findrunnable的顶部重试 再次检查全局运行队列中是否有G, 有则获取并返回 释放M拥有的P, P会变为空闲(_Pidle)状态 把P添加到"空闲P链表"中 让M离开自旋状态, 这里的处理非常重要, 参考上面的"空闲M链表" 首先减少表示当前自旋中的M的数量的全局变量nmspinning 再次检查所有P的本地运行队列, 如果不为空则让M重新进入自旋状态, 并跳到findrunnable的顶部重试 再次检查有没有待运行的GC Worker, 有则让M重新进入自旋状态, 并跳到findrunnable的顶部重试 再次检查网络事件反应器是否有待运行的G, 这里对netpoll的调用会阻塞, 直到某个fd收到了事件 如果最终还是获取不到G, 调用stopm休眠当前的M 唤醒后跳到findrunnable的顶部重试 成功获取到一个待运行的G 让M离开自旋状态, 调用resetspinning, 这里的处理和上面的不一样 如果当前有空闲的P, 但是无自旋的M(nmspinning等于0), 则唤醒或新建一个M 上面离开自旋状态是为了休眠M, 所以会再次检查所有队列然后休眠 这里离开自选状态是为了执行G, 所以会检查是否有空闲的P, 有则表示可以再开新的M执行G 如果G要求回到指定的M(例如上面的runtime.main) 调用startlockedm函数把G和P交给该M, 自己进入休眠 从休眠唤醒后跳到schedule的顶部重试 调用execute函数执行G

execute函数的处理如下:

调用getg获取当前的g 把G的状态由待运行(_Grunnable)改为运行中(_Grunning) 设置G的stackguard, 栈空间不足时可以扩张 增加P中记录的调度次数(对应上面的每61次优先获取一次全局运行队列) 设置g.m.curg = g 设置g.m = m 调用gogo函数 这个函数会根据g.sched中保存的状态恢复各个寄存器的值并继续运行g 首先针对g.sched.ctxt调用写屏障(GC标记指针存活), ctxt中一般会保存指向[函数+参数]的指针 设置TLS中的g为g.sched.g, 也就是g自身 设置rsp寄存器为g.sched.rsp 设置rax寄存器为g.sched.ret 设置rdx寄存器为g.sched.ctxt (上下文) 设置rbp寄存器为g.sched.rbp 清空sched中保存的信息 跳转到g.sched.pc 因为前面创建goroutine的newproc1函数把返回地址设为了goexit, 函数运行完毕返回时将会调用goexit函数

g.sched.pc在G首次运行时会指向目标函数的第一条机器指令, 如果G被抢占或者等待资源而进入休眠, 在休眠前会保存状态到g.sched, g.sched.pc会变为唤醒后需要继续执行的地址, "保存状态"的实现将在下面讲解.

目标函数执行完毕后会调用goexit函数, goexit函数会调用goexit1函数, goexit1函数会通过mcall调用goexit0函数. mcall这个函数就是用于实现"保存状态"的, 处理如下:

设置g.sched.pc等于当前的返回地址 设置g.sched.sp等于寄存器rsp的值 设置g.sched.g等于当前的g 设置g.sched.bp等于寄存器rbp的值 切换TLS中当前的g等于m.g0 设置寄存器rsp等于g0.sched.sp, 使用g0的栈空间 设置第一个参数为原来的g 设置rdx寄存器为指向函数地址的指针(上下文) 调用指定的函数, 不会返回

mcall这个函数保存当前的运行状态到g.sched, 然后切换到g0和g0的栈空间, 再调用指定的函数. 回到g0的栈空间这个步骤非常重要, 因为这个时候g已经中断, 继续使用g的栈空间且其他M唤醒了这个g将会产生灾难性的后果. G在中断或者结束后都会通过mcall回到g0的栈空间继续调度, 从goexit调用的mcall的保存状态其实是多余的, 因为G已经结束了.

goexit1函数会通过mcall调用goexit0函数, goexit0函数调用时已经回到了g0的栈空间, 处理如下:

把G的状态由运行中(_Grunning)改为已中止(_Gdead) 清空G的成员 调用dropg函数解除M和G之间的关联 调用gfput函数把G放到P的自由列表中, 下次创建G时可以复用 调用schedule函数继续调度

G结束后回到schedule函数, 这样就结束了一个调度循环. 不仅只有G结束会重新开始调度, G被抢占或者等待资源也会重新进行调度, 下面继续来看这两种情况.

抢占的实现

上面我提到了runtime.main会创建一个额外的M运行sysmon函数, 抢占就是在sysmon中实现的. sysmon会进入一个无限循环, 第一轮回休眠20us, 之后每次休眠时间倍增, 最终每一轮都会休眠10ms. sysmon中有netpool(获取fd事件), retake(抢占), forcegc(按时间强制执行gc), scavenge heap(释放自由列表中多余的项减少内存占用)等处理.

retake函数负责处理抢占, 流程是:

枚举所有的P 如果P在系统调用中(_Psyscall), 且经过了一次sysmon循环(20us~10ms), 则抢占这个P 调用handoffp解除M和P之间的关联 如果P在运行中(_Prunning), 且经过了一次sysmon循环并且G运行时间超过forcePreemptNS(10ms), 则抢占这个P 调用preemptone函数 设置g.preempt = true 设置g.stackguard0 = stackPreempt

为什么设置了stackguard就可以实现抢占? 因为这个值用于检查当前栈空间是否足够, go函数的开头会比对这个值判断是否需要扩张栈. stackPreempt是一个特殊的常量, 它的值会比任何的栈地址都要大, 检查时一定会触发栈扩张.

栈扩张调用的是morestack_noctxt函数, morestack_noctxt函数清空rdx寄存器并调用morestack函数. morestack函数会保存G的状态到g.sched, 切换到g0和g0的栈空间, 然后调用newstack函数. newstack函数判断g.stackguard0等于stackPreempt, 就知道这是抢占触发的, 这时会再检查一遍是否要抢占:

如果M被锁定(函数的本地变量中有P), 则跳过这一次的抢占并调用gogo函数继续运行G 如果M正在分配内存, 则跳过这一次的抢占并调用gogo函数继续运行G 如果M设置了当前不能抢占, 则跳过这一次的抢占并调用gogo函数继续运行G 如果M的状态不是运行中, 则跳过这一次的抢占并调用gogo函数继续运行G

即使这一次抢占失败, 因为g.preempt等于true, runtime中的一些代码会重新设置stackPreempt以重试下一次的抢占. 如果判断可以抢占, 则继续判断是否GC引起的, 如果是则对G的栈空间执行标记处理(扫描根对象)然后继续运行, 如果不是GC引起的则调用gopreempt_m函数完成抢占.

gopreempt_m函数会调用goschedImpl函数, goschedImpl函数的流程是:

把G的状态由运行中(_Grunnable)改为待运行(_Grunnable) 调用dropg函数解除M和G之间的关联 调用globrunqput把G放到全局运行队列 调用schedule函数继续调度

因为全局运行队列的优先度比较低, 各个M会经过一段时间再去重新获取这个G执行, 抢占机制保证了不会有一个G长时间的运行导致其他G无法运行的情况发生.

channel的实现

在goroutine运行的过程中, 有时候需要对资源进行等待, channel就是最典型的资源. channel的数据定义在这里, 其中关键的成员如下:

qcount: 当前队列中的元素数量 dataqsiz: 队列可以容纳的元素数量, 如果为0表示这个channel无缓冲区 buf: 队列的缓冲区, 结构是环形队列 elemsize: 元素的大小 closed: 是否已关闭 elemtype: 元素的类型, 判断是否调用写屏障时使用 sendx: 发送元素的序号 recvx: 接收元素的序号 recvq: 当前等待从channel接收数据的G的链表(实际类型是sudog的链表) sendq: 当前等待发送数据到channel的G的链表(实际类型是sudog的链表) lock: 操作channel时使用的线程锁

发送数据到channel实际调用的是runtime.chansend1函数, chansend1函数调用了chansend函数, 流程是:

检查channel.recvq是否有等待中的接收者的G 如果有, 表示channel无缓冲区或者缓冲区为空 调用send函数 如果sudog.elem不等于nil, 调用sendDirect函数从发送者直接复制元素 等待接收的sudog.elem是指向接收目标的内存的指针, 如果是接收目标是_则elem是nil, 可以省略复制 等待发送的sudog.elem是指向来源目标的内存的指针 复制后调用goready恢复发送者的G 切换到g0调用ready函数, 调用完切换回来 把G的状态由等待中(_Gwaiting)改为待运行(_Grunnable) 把G放到P的本地运行队列 如果当前有空闲的P, 但是无自旋的M(nmspinning等于0), 则唤醒或新建一个M 从发送者拿到数据并唤醒了G后, 就可以从chansend返回了 判断是否可以把元素放到缓冲区中 如果缓冲区有空余的空间, 则把元素放到缓冲区并从chansend返回 无缓冲区或缓冲区已经写满, 发送者的G需要等待 获取当前的g 新建一个sudog 设置sudog.elem = 指向发送内存的指针 设置sudog.g = g 设置sudog.c = channel 设置g.waiting = sudog 把sudog放入channel.sendq 调用goparkunlock函数 调用gopark函数 通过mcall函数调用park_m函数 mcall函数和上面说明的一样, 会把当前的状态保存到g.sched, 然后切换到g0和g0的栈空间并执行指定的函数 park_m函数首先把G的状态从运行中(_Grunning)改为等待中(_Gwaiting) 然后调用dropg函数解除M和G之间的关联 再调用传入的解锁函数, 这里的解锁函数会对解除channel.lock的锁定 最后调用schedule函数继续调度 从这里恢复表示已经成功发送或者channel已关闭 检查sudog.param是否为nil, 如果为nil表示channel已关闭, 抛出panic 否则释放sudog然后返回

从channel接收数据实际调用的是runtime.chanrecv1函数, chanrecv1函数调用了chanrecv函数, 流程是:

检查channel.sendq中是否有等待中的发送者的G 如果有, 表示channel无缓冲区或者缓冲区已满, 这两种情况需要分别处理(为了保证入出队顺序一致) 调用recv函数 如果无缓冲区, 调用recvDirect函数把元素直接复制给接收者 如果有缓冲区代表缓冲区已满 把队列中下一个要出队的元素直接复制给接收者 把发送的元素复制到队列中刚才出队的位置 这时候缓冲区仍然是满的, 但是发送序号和接收序号都会增加1 复制后调用goready恢复接收者的G, 处理同上 把数据交给接收者并唤醒了G后, 就可以从chanrecv返回了 判断是否可以从缓冲区获取元素 如果缓冲区有元素, 则直接取出该元素并从chanrecv返回 无缓冲区或缓冲区无元素, 接收者的G需要等待 获取当前的g 新建一个sudog 设置sudog.elem = 指向接收内存的指针 设置sudog.g = g 设置sudog.c = channel 设置g.waiting = sudog 把sudog放入channel.recvq 调用goparkunlock函数, 处理同上 从这里恢复表示已经成功接收或者channel已关闭 检查sudog.param是否为nil, 如果为nil表示channel已关闭 和发送不一样的是接收不会抛panic, 会通过返回值通知channel已关闭 释放sudog然后返回

关闭channel实际调用的是closechan函数, 流程是:

设置channel.closed = 1 枚举channel.recvq, 清零它们sudog.elem, 设置sudog.param = nil 枚举channel.sendq, 设置sudog.elem = nil, 设置sudog.param = nil 调用goready函数恢复所有接收者和发送者的G

可以看到如果G需要等待资源时, 会记录G的运行状态到g.sched, 然后把状态改为等待中(_Gwaiting), 再让当前的M继续运行其他G. 等待中的G保存在哪里, 什么时候恢复是等待的资源决定的, 上面对channel的等待会让G放到channel中的链表.

对网络资源的等待可以看netpoll相关的处理, netpoll在不同系统中的处理都不一样, 有兴趣的可以自己看看.

参考链接

https://github.com/golang/go https://golang.org/s/go11sched http://supertech.csail.mit.edu/papers/steal.pdf https://docs.google.com/document/d/1ETuA2IOmnaQ4j81AtTGT40Y4_Jr6_IDASEKg0t0dBR8/edit#heading=h.x4kziklnb8fr https://blog.altoros.com/golang-part-1-main-concepts-and-project-structure.html https://blog.altoros.com/golang-internals-part-2-diving-into-the-go-compiler.html https://blog.altoros.com/golang-internals-part-3-the-linker-and-object-files.html https://blog.altoros.com/golang-part-4-object-files-and-function-metadata.html https://blog.altoros.com/golang-internals-part-5-runtime-bootstrap-process.html https://blog.altoros.com/golang-internals-part-6-bootstrapping-and-memory-allocator-initialization.html http://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64 http://legendtkl.com/categories/golang http://www.cnblogs.com/diegodu/p/5803202.html https://www.douban.com/note/300631999/ http://morsmachine.dk/go-scheduler

legendtkl很早就已经开始写golang内部实现相关的文章了, 他的文章很有参考价值, 建议同时阅读他写的内容. morsmachine写的针对协程的分析也建议参考. golang中的协程实现非常的清晰, 在这里要再次佩服google工程师的功力, 可以写出这样简单易懂的代码不容易.

有疑问加站长微信联系(非本文作者)

本文来自:博客园

感谢作者:zkweb

查看原文:Golang源码探索(二) 协程的实现原理

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:701969077

关注微信 22959 次点击  ∙  5 赞   加入收藏 微博 赞


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3